import QuantLib as qlBonds and CDS curves
today = ql.Date(27, ql.April, 2025)
ql.Settings.instance().evaluationDate = todayWhat if you don’t have a sensible discount curve available for a corporate issue, but you have a CDS curve instead?
That’s not optimal, of course. There can be a significant basis between the CDS and bond markets (for instance, because of their different liquidity); make sure that you’re aware of any such issues before doing your calculations.
This said, let’s take a sample fixed-rate bond:
schedule = ql.Schedule(
ql.Date(8, ql.February, 2024),
ql.Date(8, ql.February, 2034),
ql.Period(6, ql.Months),
ql.UnitedStates(ql.UnitedStates.GovernmentBond),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,
False,
)
settlement_days = 3
face_amount = 10_000
coupons = [0.03]
payment_day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
bond = ql.FixedRateBond(
settlement_days, face_amount, schedule, coupons, payment_day_counter
)A risky bond engine
The RiskyBondEngine class makes it possible to calculate a price by discounting its coupons at the risk-free rate, but also weighing them by the probability that they are actually paid; that is, the probability that the issuer doesn’t default. We’ll need a risk-free curve…
dates, rates = zip(
*[
(ql.Date(27, 4, 2025), 0.02022),
(ql.Date(27, 7, 2025), 0.02064),
(ql.Date(27, 10, 2025), 0.02041),
(ql.Date(27, 4, 2026), 0.02163),
(ql.Date(27, 4, 2027), 0.02463),
(ql.Date(27, 4, 2028), 0.02718),
(ql.Date(27, 4, 2029), 0.02905),
(ql.Date(27, 4, 2030), 0.03067),
(ql.Date(27, 4, 2031), 0.03161),
(ql.Date(27, 4, 2032), 0.03232),
(ql.Date(27, 4, 2033), 0.03305),
(ql.Date(27, 4, 2034), 0.03358),
(ql.Date(27, 4, 2035), 0.03402),
(ql.Date(27, 4, 2040), 0.03565),
(ql.Date(27, 4, 2045), 0.03619),
(ql.Date(27, 4, 2050), 0.03619),
(ql.Date(27, 4, 2055), 0.03620),
]
)
risk_free_curve = ql.ZeroCurve(dates, rates, ql.Actual365Fixed())…and a default probability curve, that can be bootstrapped from CDS quotes.
cds_data = [
(ql.Period(1, ql.Years), 0.004),
(ql.Period(2, ql.Years), 0.008),
(ql.Period(3, ql.Years), 0.013),
(ql.Period(5, ql.Years), 0.021),
(ql.Period(7, ql.Years), 0.027),
(ql.Period(10, ql.Years), 0.034),
]
fixed_rate = 0.01
recovery_rate = 0.4
cds_settlement_days = 1
upfront_settlement_days = 3
helpers = [
ql.UpfrontCdsHelper(
quote,
fixed_rate,
tenor,
cds_settlement_days,
ql.UnitedStates(ql.UnitedStates.GovernmentBond),
ql.Quarterly,
ql.ModifiedFollowing,
ql.DateGeneration.CDS2015,
ql.Actual360(),
recovery_rate,
ql.YieldTermStructureHandle(risk_free_curve),
upfront_settlement_days,
)
for tenor, quote in cds_data
]
probability_curve = ql.PiecewiseFlatHazardRate(
today, helpers, ql.Actual360()
)Once we have those, we can instantiate the engine, set it to the bond, and retrieve the results we want:
bond.setPricingEngine(
ql.RiskyBondEngine(
ql.DefaultProbabilityTermStructureHandle(probability_curve),
recovery_rate,
ql.YieldTermStructureHandle(risk_free_curve),
)
)bond.cleanPrice()87.59096931191402
Back to rates
If we want to translate this into a corresponding credit spread to be applied on top of the risk-free curve, we can create a discount curve with a spread yet to be determined:
credit_spread = ql.SimpleQuote(0.0)
discount_curve = ql.ZeroSpreadedTermStructure(
ql.YieldTermStructureHandle(risk_free_curve),
ql.QuoteHandle(credit_spread),
)Then, we can use the CDS-based price as a target…
target_price = bond.cleanPrice()…and set a new engine to the bond. Next we define a function that, given a value for the spread, calculates the corresponding bond price and returns its difference from the target price; we can then pass it to a solver that finds its zero, i.e., the spread for which the bond price is the same as the CDS-based price.
bond.setPricingEngine(
ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve))
)def objective_function(s):
credit_spread.setValue(s)
return bond.cleanPrice() - target_pricesolver = ql.Brent()
spread = solver.solve(objective_function, 1e-7, 0.005, 0.0001)
spread0.013749869755662938
We can check that the price is indeed the same, within numerical tolerance:
credit_spread.setValue(spread)
bond.cleanPrice()87.59096931265552
We can also express the spread as a difference between bond yields; namely, the yield that we can calculate based on the target price…
y0 = bond.bondYield(
ql.BondPrice(target_price, ql.BondPrice.Clean),
payment_day_counter,
ql.Compounded,
ql.Semiannual,
)
y00.047452630996704104
…and the one we can obtain from a risk-free bond price, that we can obtain by using the risk-free curve for discounting:
bond.setPricingEngine(
ql.DiscountingBondEngine(ql.YieldTermStructureHandle(risk_free_curve))
)
risk_free_price = bond.cleanPrice()
risk_free_price97.4066704451667
y1 = bond.bondYield(
ql.BondPrice(risk_free_price, ql.BondPrice.Clean),
payment_day_counter,
ql.Compounded,
ql.Semiannual,
)
y10.03343150448799134
The difference between the two yields is comparable to the z-spread we calculated earlier; given the different conventions (the z-spread is continuously compounded) we didn’t expect them to be the same.
y0 - y10.014021126508712761